Bei der Entwicklung eines Gerätes mit Lithium-Ionen-Akku ist die Nachhaltigkeit nicht belanglos.
Daher stellt sich früher oder später die Frage:
Wie lange bleibt die Zelle einsatzfähig?
Bei dieser Frage soll Abhilfe geschaffen werden, und zwar mit einer datenbasierten Veranschaulichung.
Dabei wird der Blick zunächst auf die historische Entwicklung der Kapazität und des Innenwiderstand gerichtet.
Ein simpel gestaltetes, lineares Regressionsmodell simuliert den weiteren Verlauf.
Die Grafiken bieten zwei interaktive Elemente:
Slider: Der Slider dient dazu den Innenwiderstand wunschgemäss anzupassen.
Dropdown: Im Dropdown-Menü kann man die gewünschten Betriebsbedingungen wählen wie etwa die Temperatur oder den Strom.
Als Ergebnis sieht man eine simulierte Vorhersage, wie lange eine Zelle nutzbar bleibt, bevor sie ihre Grenzwerte überschreitet.
Hinzu kommt die geschätzte Wärmeentwicklung der Zelle, welche auf Basis eines Newtonschen Erwärmungsmodell basiert.
Hierbei ist besonders wichtig, bei welchem Zyklus die Zelle ihre höchste Betriebstemperatur erreicht.
Dies spielt eine wichtige Rolle für die Alterung, die Sicherheit sowie die Effizienz der Lithium-Ionen-Akkuzelle.
Jede Zeile steht für eine Testgruppe des NASA-Datasets.
• Batterien → ID-Codes der geprüften Zellen
• Temperatur → Umgebung während des Zyklierens
• Entladestrom → konstanter Strom bzw. Pulsprofil
Wähle im Dropdown später genau diese Gruppen aus, um die dazugehörigen Degradations- und Temperaturkurven anzuzeigen.
Gruppe
Batterien
Temperatur (°C)
Entladestrom
Low Temp, 1A
B0045, B0046, B0047, B0048
4
1 A
Low Temp, 2A
B0053, B0054, B0055, B0056
4
2 A
Room Temp, 2A
B0005, B0006, B0007, B0018
24
2 A
Room Temp, PWM 50% 4A
B0025, B0026, B0027, B0028
24
2 A
High Temp, 4A
B0029, B0030, B0031, B0032
44
4 A
Die zugrunde liegenden Daten stammen aus einem öffentlich zugänglichen Datensatz des Nutzers “astro_pat”(Link zu Kaggle). Messreihen zur Degradation von Lithium-Ionen-Zellen und bildet die Grundlage für sämtliche Veranschaulichungen verwendet. Weitere Details zur Auswahl und zur Bereinigung der Daten sind im Tab «Datenfilterung» ersichtlich.
Weitere Details zur Auswahl und Bereinigung der Daten findest du im Abschnitt Datenfilterung.
Das eingesetzte Prognosemodell kombiniert eine robuste lineare Regressionsmethode mit einem vereinfachten thermischen Modell nach Newton, um sowohl den Kapazitätsverfall als auch die Temperaturentwicklung von Lithium-Ionen-Zellen im Zeitverlauf abzuschätzen.
Theil‑Sen Regression
Es wird eine Gerade durch 80% der gemessenen Kapazitäts- und Innenwiderstandswerte gelegt.
Mit Hilfe eines Sliders kann der Start-Innenwiderstand angepasst werden, und über das Dropdown wird das jeweilige Belastungsprofil (Temperatur & Entladestrom) gewählt.
Die Regressionsgerade liefert eine Schätzung, bei welchem Zyklus (bzw. Lebensdauer-Punkt) die Zelle einen definierten End-of-Life-Wert (z. B. 30 % Kapazitätsverlust) erreicht.
Newtonsches Erwärmungsmodell
Parallel dazu wird die Temperaturentwicklung der Zelle mithilfe eines vereinfachten thermischen Modells berechnet:
Diese Gleichung beschreibt, wie sich die Temperaturdifferenz zur Umgebungstemperatur im Verlauf eines Entladevorgangs verändert. Die Parameter \(\alpha\) , \(A\) und \(\tau\) repräsentieren dabei den Wärmeübergangskoeffizienten (empirisch), die effektive Zelloberfläche und die thermische Zeitkonstante.
Das Modell liefert eine Schätzung, bei welchem Zyklus die maximale Betriebstemperatur erreicht wird, ein zentraler Aspekt in Bezug auf Alterung, thermische Sicherheit und Wirkungsgrad.
Die Kombination beider Ansätze ermöglicht eine einfache, aber nachvollziehbare Vorhersage: Zum einen, wann die Kapazität unter ein kritisches Niveau fällt, zum anderen, wie sich die Zelltemperatur im Laufe des Betriebs entwickelt.
Bevor die Daten für die Erstellung von Prognosen verwendet werden konnten, war eine systematische Analyse und anschliessender Bereinigung der Daten erforderlich. Zur Förderung der Nachvollziehbarkeit, werden untenstehend die zentralen Verarbeitungsschritt erläutert:
Erkennung und Bereinigung von «Ausreissern» (stark vom übrigen Datenverlauf abweichende Einzelwerte): Der Fokus lag hierbei war auf Zellmessungen mit ungewöhnlich hohen oder niedrigen Impedanzwerten („Anomalous data“). Um zu vermeiden, dass diese Werte die Aussagekraft der Analyse verfälschen oder beeinflussen, wurden sie konsequent ausgeschlossen.
Prüfung auf Vollständigkeit:
Für eine valide Auswertung konnten nur Daten berücksichtigt werden, welche in allen relevanten Aspekten vollständig vorlagen. Konkret bedeutet dies, es wurden nur Werte für «Batterien» berücksichtigt, sofern «Temperature (°C)» und «Charge/Discharge Protocol» vorhanden war. Unvollständige Einträge wurden verworfen.
Selektion relevanter Testgruppen:
Zunächst wurden jene Testgruppen definiert, welche für die Analyse relevant sind (siehe hierzu im Tab Test-Bedingung). Die Zuteilung erfolgte anhand eines Abgleichs der Werte unter der Spalte «Discharge Protocol». Nur diese Testgruppen wurden weiterverarbeitet, die übrigen Protokolle wurden ausgeklammert. Grund hierfür sind uneinheitliche Testbedingungen, die zu nicht repräsentativen Einzelwerten führen.
Der Faktor Zeit:
Es wurden nur Messungen berücksichtigt, bis die Zelle den definierten End-of-Life-Punkt («EOL Criteria») erreicht hat. Zyklen (einmal Laden und Entladen) mit Inkonsistenzen wie etwa fehlendem Zeitstempel wurden aus der Serienberechnung entfernt, um eine saubere Trendlinie zu gewährleisten.
Normalisierung zwecks Vergleichbarkeit:
Für jede Zelle wurde der Innenwiderstand und die Kapazität auf den Ausgangszustand (Zyklus 0) normiert, damit die Daten über verschiedene Testbedingungen hinweg vergleichbar sind.
Durch die beschriebenen Filterungsschritte liegen nun bereinigte und konsistente Teildatensätze vor, die als Grundlage für die anschliessende Regressionsanalyse sowie die interaktiven Visualisierungselemente (Slider und Dropdown-Menü) dienen. Auf diese Weise wird gewährleistet, dass die Prognoseergebnisse auf vergleichbarem Datenmaterial beruhen und nicht durch Ausreisser oder fehlende Werte verzerrt werden.
Interaktive Darstellung der Zellentwicklung
Plot-Anleitung: Das solltest du beachten
Was ist ersichtlich?
Obere Grafik: Dargestellt ist der zeitliche Verlauf des Innenwiderstands, der im Verlauf der Zyklen einen ansteigenden Trend aufweist.
Untere Grafik: Die untere Darstellung zeigt die Entwicklung der Kapazität, welche mit zunehmender Zyklenzahl kontinuierlich abnimmt.
Interaktive Funktionen
Element
Aktion
Effekt
🔍 Mouse-Over
Zeige auf eine Linie
Exakte Zyklus-/Messwerte als Tooltip
📑 Legende-Klick
• Einfach-Klick: Linie ein/aus • Doppel-Klick: gewünschte Linie isolieren
Fokus auf bestimmte Zellen
📂 Dropdown
Testgruppe wählen
Zeigt Kurven des gewählten Belastungsprofils
Warum Zyklen statt Zeit?
Die Zellalterung wird primär durch Nutzung bestimmt. Ein Zyklus (Laden + Entladen) sagt daher mehr über Degradation aus als reine Kalendertage. Zyklen stellen daher eine geeignetere Referenzgrösse zur Bewertung des Alterungsverlaufs dar.
Interpretationshinweis
Achte auf den Punkt, an dem die Kapazitätskurve 30 % unter Nennwert unter den ursprünglichen Nennwert fällt. Dieser Schwellenwert markiert typischerweise das Erreichen des End-of-Life-Zustands der Zelle.
Code
import numpy as npimport pandas as pdfrom IPython.display import displayimport refrom datetime import datetimeimport plotly.graph_objs as godf = pd.read_pickle("../1_Data/merged_dataset.pkl")import os, picklemodel_path ="../1_Data/degradation_model.pkl"if os.path.exists(model_path):#print(f"INFO: Lade Modellcache {model_path}")withopen(model_path, "rb") as f: cache = pickle.load(f) agg_df = cache["agg_df"] cap_agg = cache["cap_agg"] skip_training =Trueelse:#print("INFO: Kein Modellcache, Training wird ausgeführt …") skip_training =False# Berechne Kapazität aus Rohdaten (Discharge)# Stelle sicher, dass Current_load und Battery_current numerisch sinddf["Current_load"] = pd.to_numeric(df["Current_load"], errors="coerce")df["Battery_current"] = pd.to_numeric(df["Battery_current"], errors="coerce")# Robuster Discharge‑Filter:dis_mask = ( df["type"].str.contains("discharge", case=False, na=False)) | ( pd.to_numeric(df["Current_load"], errors="coerce") <-0.01) | ( pd.to_numeric(df["Battery_current"], errors="coerce") <-0.01)df_discharge_raw = df[dis_mask].copy()df_discharge_raw["dt"] = ( df_discharge_raw .sort_values(by=["battery_id", "test_id", "Time"]) .groupby(["battery_id", "test_id"])["Time"] .diff() .fillna(0))# Berechne Energie ausschliesslich aus Current_load mit angenommener Nominalspannung 3.7Vdf_discharge_raw["energy_Wh"] = ( df_discharge_raw["Current_load"].abs()*3.7* df_discharge_raw["dt"]/3600)# Kapazität in Ah pro Zyklus (Energy Wh / Nominalspannung ~3.7V)cap_per_cycle = ( df_discharge_raw .groupby(["battery_id", "test_id"])["energy_Wh"] .sum() .reset_index() .rename(columns={"energy_Wh": "Capacity_Ah"}))cap_per_cycle["Capacity_Ah"] = cap_per_cycle["Capacity_Ah"] /3.7# Merge Kapazitätswerte zurück in den Haupt-DataFramedf = df.merge( cap_per_cycle[["battery_id", "test_id", "Capacity_Ah"]], on=["battery_id", "test_id"], how="left")# Debug-Ausgabe, um den Merge-Erfolg zu prüfen#print("DEBUG: Nicht-NA-Anzahl in 'Capacity_Ah' nach Berechnung:", df["Capacity_Ah"].notna().sum())# DataFrame für Re-Berechnung sicherndf_for_re = df.copy()# Lade separate Kapazitätsdaten und merge mit den Impedanzdaten# (Passe den Pfad "../1_Data/capacity_dataset.pkl" an, falls nötig)try: df_cap = pd.read_pickle("../1_Data/capacity_dataset.pkl") df_cap["Capacity"] = pd.to_numeric(df_cap["Capacity"], errors="coerce")exceptFileNotFoundError:#print("WARNUNG: capacity_dataset.pkl nicht gefunden. Kapazitätsdaten werden übersprungen.") df_cap = pd.DataFrame(columns=["battery_id", "test_id", "Capacity"])# Sicherstellen, dass die benötigten Spalten existieren und numerisch sind# Merge auf Basis von battery_id und test_iddf = df.merge( df_cap[["battery_id", "test_id", "Capacity"]], on=["battery_id", "test_id"], how="left", suffixes=("", "_cap"))# Debug-Ausgabe, um den Merge-Erfolg zu prüfen##print("DEBUG: Nicht-NA-Anzahl in 'Capacity_cap' nach Merge:", df["Capacity_cap"].notna().sum())df_for_re["Re"] = pd.to_numeric(df_for_re["Re"], errors="coerce")df_for_re = df_for_re[(df_for_re["Re"] >0.03) & (df_for_re["Re"] <0.15)]# Testgruppen definierengruppen = {"Low Temp, 1A": ["B0045", "B0046", "B0047", "B0048"],"Low Temp, 2A": ["B0053", "B0054", "B0055", "B0056"],"Room Temp, 2A": ["B0005", "B0006","B0007", "B0018"],"Room Temp, PWM 50% 4A": ["B0025", "B0026", "B0027", "B0028"],"High Temp, 4A": ["B0029", "B0030", "B0031", "B0032"]#"Low Temp, dynamic load": ["B0041", "B0042", "B0043", "B0044"]#"Room Temp, 2A": "B0007"#"Room Temp, PWM 50% 4A": }# -------- Einheitliche Degradation-Visualisierung mit Debugging --------from plotly.subplots import make_subplots# Berechne und lade alle Re-Traces und Kapazitätsdaten einmal# Re-Traces pro Gruppeall_re_traces = []re_traces_per_group = []for i, (grp, ids) inenumerate(gruppen.items()): group_count =0for bid in ids: d = df_for_re[df_for_re["battery_id"] == bid].copy()#print(f"DEBUG: Loaded Re data for Group = {grp}, Battery ID = {bid}, Rows = {len(d)}") series = d["Re"].interpolate(method='linear', limit_direction='both') smooth_re = series.rolling(window=20, center=True, min_periods=1).mean() all_re_traces.append(go.Scatter( x=d["test_id"], y=smooth_re, name=f"{bid} (Re)", visible=(i ==0), hoverlabel=dict(bgcolor="#dddddd", font=dict(color="#000", family="Courier New", size=12), namelength=-1), hovertemplate="Zyklus %{x}<br>Re %{y:.4f} Ω" )) group_count +=1 re_traces_per_group.append(group_count)# Debug: Zeige alle eindeutigen Werte in df["type"]##print("DEBUG: Einzigartige Werte in df['type']:", df["type"].unique())# Debug: Zeige alle Spaltennamen im DataFrame#print("DEBUG: Spalten in df:", df.columns.tolist())#print("DEBUG: Nicht-NA-Anzahl in 'Capacity_Ah':", df["Capacity_Ah"].notna().sum())# Kapazitätsdaten vorbereiten und Traces pro Gruppeif df["Capacity_Ah"].notna().sum() ==0:#print("WARNUNG: Keine Kapazitätsdaten verfügbar, überspringe Kapazitäts-Plot.")# Erzeuge nur Re-Plot ohne Kapazitäts-Subplot fig = go.Figure(data=all_re_traces) buttons_re = [] total_re_traces =sum(re_traces_per_group)for i, grp inenumerate(gruppen.keys()): vis_re = [False] * total_re_traces start_re =sum(re_traces_per_group[:i])for j inrange(re_traces_per_group[i]): vis_re[start_re + j] =True buttons_re.append(dict( label=grp, method="update", args=[{"visible": vis_re}, {"title.text": f"Re-Verlauf – {grp}"}] )) fig.update_layout( updatemenus=[dict( active=0, buttons=buttons_re, direction="down", x=0.7, y=1.25, xanchor="left", yanchor="top", bgcolor="#1e1e1e", bordercolor="#444", borderwidth=1, font=dict(family="Courier New", size=14, color="#f0f0f0") )], template="plotly_dark", font=dict(family="Courier New", size=14, color="#f0f0f0"), colorway=["#e41a1c", "#377eb8", "#4daf4a", "#984ea3"], paper_bgcolor="#1e1e1e", plot_bgcolor="#1e1e1e", height=1000, # vergrössert Plotbereich, Y‑Achsen erscheinen ~ doppelt so hoch width=760, margin=dict(l=50, r=160, t=40, b=100), xaxis=dict( title=dict( text="Zyklen", font=dict(family="Courier New", size=16, color="#f0f0f0") ), tickfont=dict(family="Courier New", size=14, color="#f0f0f0"), title_standoff=20 ), yaxis=dict( title=dict( text="Re (Ω)", font=dict(family="Courier New", size=16, color="#f0f0f0") ), tickfont=dict(family="Courier New", size=14, color="#f0f0f0") ), legend=dict( title="Akkuzellen", orientation="v", x=1.02, y=1, font=dict(family="Courier New", size=12, color="#f0f0f0"), bgcolor="#1e1e1e" ), hovermode="x unified" ) fig.show()else: df_discharge = df[df["Capacity_Ah"].notna()].copy() df_discharge["Capacity_Ah"] = pd.to_numeric(df_discharge["Capacity_Ah"], errors="coerce") df_discharge = df_discharge.dropna(subset=["Capacity_Ah"]) df_discharge = df_discharge.sort_values(by=["battery_id", "test_id"]) valid_ids_dict = {} all_cap_traces = [] cap_traces_per_group = []for i, (grp, ids) inenumerate(gruppen.items()): valid_ids = [bid for bid in ids ifnot df_discharge[df_discharge["battery_id"] == bid].empty] valid_ids_dict[grp] = valid_ids group_count =0for bid in valid_ids: d_cap = df_discharge[df_discharge["battery_id"] == bid].copy()#print(f"DEBUG: Loaded Capacity data for Group = {grp}, Battery ID = {bid}, Rows = {len(d_cap)}")# Finde den ersten Zyklus mit positiver Kapazität positive_cycles = d_cap[d_cap["Capacity_Ah"] >0]if positive_cycles.empty:#print(f"DEBUG: Keine positiven Kapazitätsdaten für {bid}, skip.")pass first_cycle = positive_cycles["test_id"].min() cap0 = d_cap.loc[d_cap["test_id"] == first_cycle, "Capacity_Ah"].iloc[0]if cap0 <0.05:#print(f"DEBUG: cap0 ({cap0:.3f} Ah) zu klein – {bid} übersprungen.")pass#print(f"DEBUG: cap0 for {bid} (first positive cycle {first_cycle}) = {cap0}") d_cap["cap_pct"] = d_cap["Capacity_Ah"] / cap0 *100 smooth_cap = d_cap["cap_pct"].rolling(window=10, center=True, min_periods=1).mean() all_cap_traces.append(go.Scatter( x=d_cap["test_id"], y=smooth_cap, name=f"{bid} (Kap)", visible=(i ==0), hoverlabel=dict(bgcolor="#dddddd", font=dict(color="#000", family="Courier New", size=12), namelength=-1), hovertemplate="Zyklus %{x}<br>Kapazität %{y:.1f}%" )) group_count +=1 cap_traces_per_group.append(group_count)# Debug-Ausgabe: Zeige valid_ids pro Gruppe und Anzahl von Kapazitätstraces#print(f"DEBUG: valid_ids_dict = {valid_ids_dict}")#print(f"DEBUG: cap_traces_per_group = {cap_traces_per_group}")#print(f"DEBUG: total_cap_traces = {sum(cap_traces_per_group)}")# Erstelle Subplots: Re oben (Row 1), Kapazität unten (Row 2) fig_combo = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.08, subplot_titles=("Re-Verlauf", "Kapazitäts-Verlauf") )# Füge alle Traces hinzufor trace in all_re_traces: fig_combo.add_trace(trace, row=1, col=1)for trace in all_cap_traces: fig_combo.add_trace(trace, row=2, col=1)# Erzeuge Dropdown-Buttons buttons = [] total_re_traces =sum(re_traces_per_group) total_cap_traces =sum(cap_traces_per_group) total_traces = total_re_traces + total_cap_tracesfor i, grp inenumerate(gruppen.keys()): vis = [False] * total_traces# Re-Traces dieser Gruppe sichtbar schalten start_re =sum(re_traces_per_group[:i])for j inrange(re_traces_per_group[i]): vis[start_re + j] =True# Kapazitäts-Traces dieser Gruppe sichtbar schalten start_cap = total_re_traces +sum(cap_traces_per_group[:i])for j inrange(cap_traces_per_group[i]): vis[start_cap + j] =True buttons.append(dict( label=grp, method="update", args=[ {"visible": vis}, {"title.text": f"Degradation – {grp}"} ] ))# Layout anpassen fig_combo.update_layout( updatemenus=[dict( active=next((idx for idx,c inenumerate(cap_traces_per_group) if c>0), 0), buttons=buttons, direction="down", xanchor="left", yanchor="top", x=0.7, y=1.13, pad={"r": 10, "t": 10}, showactive=True, bgcolor="#1e1e1e", bordercolor="#444", borderwidth=1, font=dict( family="Courier New", size=14, color="rgb(116, 113, 113) ") )], template="plotly_dark", height=1000, # grösserer Plotbereich – Y‑Achsen ~ doppelte Höhe width=760, font=dict(family="Courier New", size=14, color="#f0f0f0"), paper_bgcolor="#1e1e1e", plot_bgcolor="#1e1e1e", margin=dict(l=60,r=180,t=120,b=150), legend=dict( title="Akkuzellen", orientation="v", x=1.02, y=1, font=dict(family="Courier New", size=12, color="#f0f0f0"), bgcolor="#1e1e1e"), title="Degradation – Low Temp, 1A", xaxis=dict(title="Zyklen", autorange=True ), xaxis2=dict(title="Zyklen", autorange=True ), yaxis=dict(title="Re (Ω)", range=[0.03, 0.15], fixedrange=True), yaxis2=dict(title="Kapazität (%)", rangemode="nonnegative", fixedrange=True), hovermode="x unified", showlegend=True )fig_combo.show()